prefviz

Visualize preferential data using ternary plots in 2D and higher dimensions

Our inspiration and challenges

ABC News shows interesting plot on 2025 House of Rep’s distribution of first preference

But some challenges with this plot:

  1. How can we quickly tell the coordinates, corresponding electorate, and other context? -> Need interactivity
  2. How can we include more parties in the visualization? -> Need to visualize points in simplex of higher-dimensions

=> prefviz creates a uniform way of visualizing ternary plot of any dimensions

We make 2D ternary plot interactive…

First Preference Distribution

Hover to see electorate names and exact vote percentages.

We make 2D ternary plot interactive…

Full Preference Flow

Track how votes move between parties as candidates are eliminated.

We make 2D ternary plot interactive…

Explore with map of electorate

Link the ternary plot with a map of electorates. Selecting a point in the ternary plot highlights the corresponding electorate on the map, and vice versa.

And we make interactive high-dimensional ternary plot

For elections with 4+ significant parties, we integrate with tourr and detourr for dynamic rotations through preference space.

Overview of prefviz functions

Step Data Transformation Construct Ternary Components Visualization
What it does Convert raw ballot data to aggregated compositional percentages Build geometric infrastructure (vertices, edges, coordinates) Interactive 2D or high-dimensional plots
Key functions dop_irv() – for raw ballot data
dop_transform() – reshape aggregated data
ternable() – create ternable object
get_tern_*() - Getter functions for transforming components to appropriate shapes
ggplot2 + plotly (2D)
tourr + detourr (high-D)
Input Raw ballots or aggregated preferences Standardized compositional data Ternary components from ternable object
Output Clean, standardized format Geometric objects ready to plot Interactive, explorable visualization

Recreate the example

Step 1: Transformation from raw to compositional data

Raw data is from AEC’s 2025 House of Representatives distribution of preferences.

Instead of raw ballot data, AEC provides aggregated percentage, with each row representing the preference for a party in a round of voting (CountNumber), at a specific electorate (DivisionNm).

Code
pref25_2d <- aecdop_2025 |> 
  filter(CalculationType == "Preference Percent") |>
  mutate(Party = case_when(
    !(PartyAb %in% c("LP", "ALP", "NP", "LNP", "LNQ")) ~ "Other", 
    PartyAb %in% c("LP", "NP", "LNP", "LNQ") ~ "LNP",
    TRUE ~ PartyAb
  ))
print(head(pref25_2d, 5), width = Inf)
# A tibble: 5 × 15
  StateAb DivisionID DivisionNm CountNumber BallotPosition CandidateID Surname 
  <chr>        <dbl> <chr>            <dbl>          <dbl>       <dbl> <chr>   
1 ACT            318 Bean                 0              1       41076 LAMERTON
2 ACT            318 Bean                 0              2       41682 PRICE   
3 ACT            318 Bean                 0              3       41436 CARTER  
4 ACT            318 Bean                 0              4       40676 SMITH   
5 ACT            318 Bean                 1              1       41076 LAMERTON
  GivenNm PartyAb PartyNm                Elected HistoricElected
  <chr>   <chr>   <chr>                  <chr>   <chr>          
1 David   LP      Liberal                N       N              
2 Jessie  IND     Independent            N       N              
3 Sam     GRN     Australian Greens      N       N              
4 David   ALP     Australian Labor Party Y       Y              
5 David   LP      Liberal                N       N              
  CalculationType    CalculationValue Party
  <chr>                         <dbl> <chr>
1 Preference Percent             23.0 LNP  
2 Preference Percent             26.4 Other
3 Preference Percent              9.5 Other
4 Preference Percent             41.0 ALP  
5 Preference Percent             23.3 LNP  

Step 1: Transformation from raw to compositional data

Transformed data with 3 columns representing the composition of each party in each electorate, summing to 1.

pref25_2d <- dop_transform(
  data = pref25_2d,
  key_cols = c(DivisionNm, CountNumber),
  value_col = CalculationValue,
  item_col = Party,
  winner_col = Elected,
  winner_identifier = "Y"
)
pref25_2d
# A tibble: 976 × 6
   DivisionNm CountNumber   ALP   LNP Other Winner
   <chr>            <dbl> <dbl> <dbl> <dbl> <chr> 
 1 Adelaide             0 0.465 0.242 0.294 ALP   
 2 Adelaide             1 0.467 0.242 0.291 ALP   
 3 Adelaide             2 0.476 0.244 0.279 ALP   
 4 Adelaide             3 0.483 0.249 0.268 ALP   
 5 Adelaide             4 0.493 0.285 0.222 ALP   
 6 Adelaide             5 0.691 0.309 0     ALP   
 7 Aston                0 0.373 0.377 0.251 ALP   
 8 Aston                1 0.373 0.378 0.249 ALP   
 9 Aston                2 0.376 0.380 0.244 ALP   
10 Aston                3 0.378 0.384 0.238 ALP   
# ℹ 966 more rows

Step 2: Get components of ternary plots

ternable() creates a ternable object, which is a S3 object that contains the data and metadata necessary for ternary plots, including the vertices, edges, labels of the simplex, and coordinates of all data points.

tern_2d <- ternable(pref25_2d, items = ALP:Other)
tern_2d
Ternable object
----------------
Items: ALP, LNP, Other 
Vertices: 3 
Edges: 6 
str(tern_2d)
List of 6
 $ data            : tibble [976 × 6] (S3: tbl_df/tbl/data.frame)
  ..$ DivisionNm : chr [1:976] "Adelaide" "Adelaide" "Adelaide" "Adelaide" ...
  ..$ CountNumber: num [1:976] 0 1 2 3 4 5 0 1 2 3 ...
  ..$ ALP        : num [1:976] 0.465 0.467 0.476 0.483 0.493 ...
  ..$ LNP        : num [1:976] 0.242 0.242 0.244 0.249 0.285 ...
  ..$ Other      : num [1:976] 0.294 0.291 0.279 0.268 0.222 ...
  ..$ Winner     : chr [1:976] "ALP" "ALP" "ALP" "ALP" ...
 $ ternary_coord   : tibble [976 × 2] (S3: tbl_df/tbl/data.frame)
  ..$ x1: num [1:976] 0.158 0.159 0.164 0.165 0.147 ...
  ..$ x2: num [1:976] 0.0487 0.0522 0.0662 0.0801 0.1367 ...
 $ data_edges      : int [1:976, 1:2] 1 2 3 4 5 6 7 8 9 10 ...
  ..- attr(*, "dimnames")=List of 2
  .. ..$ : NULL
  .. ..$ : chr [1:2] "Var1" "Var2"
 $ simplex_vertices: tibble [3 × 3] (S3: tbl_df/tbl/data.frame)
  ..$ x1    : num [1:3] 0.707 -0.707 0
  ..$ x2    : num [1:3] 0.408 0.408 -0.816
  ..$ labels: chr [1:3] "ALP" "LNP" "Other"
 $ simplex_edges   : int [1:6, 1:2] 2 3 1 3 1 2 1 1 2 2 ...
  ..- attr(*, "dimnames")=List of 2
  .. ..$ : chr [1:6] "2" "3" "4" "6" ...
  .. ..$ : chr [1:2] "Var1" "Var2"
 $ vertex_labels   : chr [1:3] "ALP" "LNP" "Other"
 - attr(*, "class")= chr "ternable"

Step 3: Visualization (2D)

prefviz includes some ggplot2 extensions to make creating ternary plots easier. Output is compatible with plotly and ggiraph.

input_data <- get_tern_data(tern_2d, plot_type = "2D") |> 
  mutate(text = paste0(DivisionNm, "\n",
                "ALP: ", round(ALP, 1), "%\n",
                "LNP: ", round(LNP, 1), "%\n",
                "Other: ", round(Other, 1), "%"))

p2d <- ggplot(input_data |> filter(CountNumber == 0), aes(x = x1, y = x2)) +
  geom_ternary_cart() + 
  geom_ternary_region(
    aes(fill = after_stat(vertex_labels)),
    x1 = 1/3, x2 = 1/3, x3 = 1/3,
    vertex_labels = tern_2d$vertex_labels,
    alpha = 0.3, color = NA, show.legend = FALSE
  ) +
  add_vertex_labels(tern_2d$simplex_vertices) + 
  geom_point(aes(color = Winner, text = text)) + 
  scale_fill_manual(
    values = c("ALP" = "red", "LNP" = "blue", "Other" = "grey70")
  ) +
  scale_color_manual(
    values = c("ALP" = "red", "LNP" = "blue", "Other" = "grey70"),
    name = "Elected Party"
  ) +
  labs(title = "First preference in 2022 Australian Federal election")

plotly_ternary <- ggplotly(p2d, tooltip = "text", width = 600, height = 400)

Step 3: Visualization (High Dimensions)

prefviz depends on tourr for high-dimensional visualization, and detourr for interactive tours.

color_vector <- c(rep("black", 5),
  party_colors[pref25_hd$Winner])

# Animate the tour
animate_xy(
  get_tern_data(tern_hd, plot_type = "HD"), 
  edges = get_tern_edges(tern_hd),
  obs_labels  = get_tern_labels(tern_hd),
  col = color_vector,
  axes = "bottomleft"
)

Current status

Priorities

  • CRAN submission by end of February
  • First draft of the paper for R journal

Potential avenues

  • Extend the application of ternary plots. The primary focus is on distribution of preferences under IRV, but the simplex space has potential to show the margin of victory, or compare results of different electoral systems.
  • Make the package more user-friendly. Customization options are limited, especially for detourr integration.
  • Run elections under different voting systems. The transformation functions are currently limited to IRV.